为什么会有 Hooks?
随着 React Hooks 在正式版本的实装,Hooks 使 React 以一种全新的编程范式定义了前端开发约束,它为视图开发带来了一种全新的心智模型。 在 hooks 被引入之前,React 的设计理念是这样的
- React 认为,UI视图是数据的一种视觉映射,即
UI = F(DATA)
,这里的F
需要负责对输入数据进行加工、并对数据的变更做出响应。 - 公式里的
F
在 React 里抽象成组件,React 是以组件Component-Based
为粒度编排应用的,组件是代码复用的最小单元。 - 在设计上,React 采用 props 属性来接收外部的数据,使用 state 属性来管理组件自身产生的数据(状态),而为了实现运行时对数据变更做出响应需要,React采用基于类
Class
的组件设计。 - React 认为组件是有生命周期的,因此开创性地将生命周期的概念引入到了组件设计,从组件的 create 到 destory 提供了一系列生命周期钩子供开发者使用。 一个 Component-Based 的组件是长这样的:
1// React基于Class设计组件2class MyConponent extends React.Component {3 // 组件自身产生的数据4 state = {5 counts: 06 }78 // 响应数据变更9 clickHandle = () => {10 this.setState({ counts: this.state.counts++ });11 if (this.props.onClick) this.props.onClick();12 }1314 // lifecycle API15 componentWillUnmount() {16 console.log('Will mouned!');17 }1819 // lifecycle API20 componentDidMount() {21 console.log('Did mouned!');22 }2324 // 接收外来数据(或加工处理),并编排数据在视觉上的呈现25 render(props) {26 return (27 <>28 <div>Input content: {props.content}, btn click counts: {this.state.counts}</div>29 <button onClick={this.clickHandle}>Add</button>30 </>31 );32 }33}
Class Component 的问题
组件复用困局
组件并不是单纯的信息孤岛,组件之间是可能会产生联系的,一方面是数据的共享,另一个是功能的复用:
- 对于组件之间的数据共享问题,React官方采用单向数据流Flux来解决
- 对于(有状态)组件的复用,React团队给出过许多的方案。从早起的
CreateClass + Mixins
,到后来设计了Render Props + Higher Order Component
,之后现在的Function Component + Hooks
的设计 HOC 的缺陷: - 嵌套地狱,每一次 HOC 调用都会产生一个组件实例
- 可以使用类装饰器缓解组件嵌套带来的可维护性问题,但装饰器本质上还是 HOC
- 包裹太多层级之后,可能会带来 props 属性的覆盖问题 Render Props 的缺陷:
- 数据流向更直观了,子孙组件可以很明确地看到数据来源
- 但本质上Render Props是基于闭包实现的,大量地用于组件的复用将不可避免地引入了callback hell问题
- 丢失了组件的上下文,因此没有 this.props 属性,不能像 HOC 那样访问 this.props.children
Javascript Class 的缺陷
- this 指向问题
1class People extends Component {2 state = {3 name: 'dm',4 age: 18,5 }67 handleClick(e) {8 // 报错!9 console.log(this.state);10 }1112 render() {13 const { name, age } = this.state;14 return (<div onClick={this.handleClick}>My name is {name}, i am {age} years old.</div>);15 }16}
createClass 不需要处理 this 的指向,到了 Class Component 稍微不慎就会出现因 this 的指向报错。 2. 编译size(还有性能)问题:
1// Class Component2class App extends Component {3 state = {4 count: 05 }67 componentDidMount() {8 console.log('Did mount!');9 }1011 increaseCount = () => {12 this.setState({ count: this.state.count + 1 });13 }1415 decreaseCount = () => {16 this.setState({ count: this.state.count - 1 });17 }1819 render() {20 return (21 <>22 <h1>Counter</h1>23 <div>Current count: {this.state.count}</div>24 <p>25 <button onClick={this.increaseCount}>Increase</button>26 <button onClick={this.decreaseCount}>Decrease</button>27 </p>28 </>29 );30 }31}3233// Function Component34function App() {35 const [ count, setCount ] = useState(0);36 const increaseCount = () => setCount(count + 1);37 const decreaseCount = () => setCount(count - 1);3839 useEffect(() => {40 console.log('Did mount!');41 }, []);4243 return (44 <>45 <h1>Counter</h1>46 <div>Current count: {count}</div>47 <p>48 <button onClick={increaseCount}>Increase</button>49 <button onClick={decreaseCount}>Decrease</button>50 </p>51 </>52 );53}
Class Component 编译结果:
1var App_App = function (_Component) {2 Object(inherits["a"])(App, _Component);34 function App() {5 var _getPrototypeOf2;6 var _this;7 Object(classCallCheck["a"])(this, App);8 for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {9 args[_key] = arguments[_key];10 }11 _this = Object(possibleConstructorReturn["a"])(this, (_getPrototypeOf2 = Object(getPrototypeOf["a"])(App)).call.apply(_getPrototypeOf2, [this].concat(args)));12 _this.state = {13 count: 014 };15 _this.increaseCount = function () {16 _this.setState({17 count: _this.state.count + 118 });19 };20 _this.decreaseCount = function () {21 _this.setState({22 count: _this.state.count - 123 });24 };25 return _this;26 }27 Object(createClass["a"])(App, [{28 key: "componentDidMount",29 value: function componentDidMount() {30 console.log('Did mount!');31 }32 }, {33 key: "render",34 value: function render() {35 return react_default.a.createElement(/*...*/);36 }37 }]);38 return App;39}(react["Component"]);
Function Component编译结果:
1function App() {2 var _useState = Object(react["useState"])(0),3 _useState2 = Object(slicedToArray["a" /* default */ ])(_useState, 2),4 count = _useState2[0],5 setCount = _useState2[1];6 var increaseCount = function increaseCount() {7 return setCount(count + 1);8 };9 var decreaseCount = function decreaseCount() {10 return setCount(count - 1);11 };12 Object(react["useEffect"])(function () {13 console.log('Did mount!');14 }, []);15 return react_default.a.createElement();16}
- Javascript实现的类本身比较鸡肋,没有类似Java/C++多继承的概念,类的逻辑复用是个问题
- Class Component 在 React 内部是当做 Javascript Function 类来处理的
- Function Component 编译后就是一个普通的 function,function对js引擎是友好的
Function Component缺失的功能
不是所有组件都需要处理生命周期,在React发布之初Function Component被设计了出来,用于简化只有render时Class Component的写法。
- Function Component是纯函数,利于组件复用和测试。
- Function Component的问题是只是单纯地接收props、绑定事件、返回jsx,本身是无状态的组件,依赖props传入的handle来响应数据(状态)的变更,所以Function Component不能脱离Class Comnent来存在。
1function Child(props) {2 const handleClick = () => {3 this.props.setCounts(this.props.counts);4 };56 // UI的变更只能通过Parent Component更新props来做到!!7 return (8 <>9 <div>{this.props.counts}</div>10 <button onClick={handleClick}>increase counts</button>11 </>12 );13}1415class Parent extends Component() {16 // 状态管理还是得依赖Class Component17 counts = 01819 render () {20 const counts = this.state.counts;21 return (22 <>23 <div>sth...</div>24 <Child counts={counts} setCounts={(x) => this.setState({counts: counts++})} />25 </>26 );27 }28}
所以,Function Comonent是否能脱离Class Component独立存在,关键在于让Function Comonent自身具备状态处理能力,即在组件首次render之后,“组件自身能够通过某种机制再触发状态的变更并且引起re-render”,而这种“机制”就是Hooks! Hooks的出现弥补了Function Component相对于Class Component的不足,让Function Component取代Class Component成为可能。 项目中也实践了很多hooks,但不成熟的使用方式会导致很多诡异的 bug,在此记录一下在踩过的坑和解决方案。
在用 Hooks 之前你需要做什么?
- 仔细阅读 React Hooks 官方文档
- 工程引入 hooks 相关 lint,开启规则
lint插件:https://www.npmjs.com/package/eslint-plugin-react-hooks
1{2 "plugins": ["react-hooks"],3 "rules": {4 "react-hooks/rules-of-hooks": "error",5 "react-hooks/exhaustive-deps": "warn"6 }7}
其中, react-hooks/exhaustive-deps 至少warn,也可以是error。建议全新的工程直接配”error”,历史工程配”warn”。 3. 如若有发现hooks相关lint导致的warning,不要全局autofix 除了hooks外,正常的lint基本不会改变代码逻辑,只是调整编写规范。但是hooks的lint规则不同, exhaustive-deps 的变化会导致代码逻辑发生变化,这极容易引发线上问题,所以对于hooks的waning,请不要做全局autofix操作。除非保证每处逻辑都做到了充分回归。 建议开启vscode的「autofix on save」。这样能把error与warning遏制在开发阶段,保证自测跟测试时就是符合规则的代码。
依赖问题
依赖与闭包问题是一定要开启exhaustive-deps 的核心原因。最常见的错误即:mount时绑定事件,后续状态更新出错。 错误代码示例:(此处用addEventListener做onclick绑定,只是为了方便说明情况)
1function ErrorDemo() {2 const [count, setCount] = useState(0);3 const dom = useRef(null);4 useEffect(() => {5 dom.current.addEventListener('click', () => setCount(count + 1));6 }, []);7 return <div ref={dom}>{count}</div>;8}
这段代码的初始想法是:每当用户点击dom,count就加1。理想中的效果是一直点,一直加。但实际效果是 {count} 到「1」以后就加不上了。
我们来梳理一下, useEffect(fn, []) 代表只会在mount时触发。也即是首次render时,fn执行一次,绑定了点击事件,点击触发 setCount(count + 1) 。乍一想,count还是那个count,肯定会一直加上去呀,当然现实在啪啪打脸。
状态变更 触发 页面渲染的本质是什么?本质就是 ui = fn(props, state, context) 。props、内部状态、上下文的变更,都会导致渲染函数(此处就是ErrorDemo)的重新执行,然后返回新的view。
那现在问题来了, ErrorDemo 这个函数执行了多次,第一次函数内部的 count 跟后面几次的 count 会有关系吗?这么一想,感觉又应该没有关系了。那为什么 第二次又知道 count 是1,而不是0了呢?第一次的 setCount 跟后面的是同一个函数吗?这背后涉及到hooks的一些底层原理,也关系到了为什么hooks的声明需要声明在函数顶部,不允许在条件语句中声明。在这里就不多讲了。
结论是:每次 count 都是重新声明的变量,指向一个全新的数据;每次的 setCount 虽然是重新声明的,但指向的是同一个引用。
回到正题,我们知道了每次render,内部的count其实都是全新的一个变量。那我们绑定的点击事件方法,也即:setCount(count + 1) ,这里的count,其实指的一直是首次render时的那个count,所以一直是0 ,因此 setCount,一直是设置count为1。
那这个问题怎么解?
首先,应该遵守前面的硬性要求,必须要加lint规则,并开启autofix on save。然后就会发现,其实这个 effect 是依赖 count 的。autofix 会帮你自动补上依赖,代码变成这样:
1useEffect(() => {2 dom.current.addEventListener('click', () => setCount(count + 1));3}, [count]);
那这样肯定就不对了,相当于每次count变化,都会重新绑定一次事件。所以对于事件的绑定,或者类似的场景,有几种思路,我按我的常规处理优先级排列:
- 消除依赖
在这个场景里,很简单,我们主要利用 setCount 的另一个用法 functional updates。这样写就好了:
() => setCount(prevCount => ++prevCount)
,不用关心什么新的旧的、什么闭包,省心省事。 - 重新绑定事件 那如果我们这个事件就是要消费这个count怎么办?比如这样:
1dom.current.addEventListener('click', () => {2 console.log(count);3 setCount(prevCount => ++prevCount);4});
我们不必执着于一定只在mount时执行一次。也可以每次重新render前移除事件,render后绑定事件即可。这里利用useEffect的特性,具体可以自己看文档:
1useEffect(() => {2 const $dom = dom.current;3 const event = () => {4 console.log(count);5 setCount(prev => ++prev);6 };7 $dom.addEventListener('click', event);8 return () => $dom.removeEventListener('click', event);9}, [count]);
- 如果嫌这样开销大,或者编写麻烦,也可以用 useRef, 其实用 useRef 也挺麻烦的,我个人不太喜欢这样操作,但也能解决问题,代码如下:
1const [count, setCount] = useState(0);2const countRef = useRef(count);3useEffect(() => {4 dom.current.addEventListener('click', () => {5 console.log(countRef.current);6 setCount(prevCount => {7 const newCount = ++prevCount;8 countRef.current = newCount;9 return newCount;10 });11 });12}, []);
useCallback 与 useMemo
这两个api,其实概念上还是很好理解的,一个是「缓存函数」, 一个是缓存「函数的返回值」。但我们经常会懒得用,甚至有的时候会用错。
从上面依赖问题我们其实可以知道,hooks对「有没有变化」这个点其实很敏感。如果一个effect内部使用了某数据或者方法。若我们依赖项不加上它,那很容易由于闭包问题,导致数据或方法,都不是我们理想中的那个它。如果我们加上它,很可能又会由于他们的变动,导致effect疯狂的执行。真实开发的话,大家应该会经常遇到这种问题。
所以,在此建议:
- 在组件内部,那些会成为其他useEffect依赖项的方法,建议用 useCallback 包裹,或者直接编写在引用它的useEffect中。
- 己所不欲勿施于人,如果你的function会作为props传递给子组件,请一定要使用 useCallback 包裹,对于子组件来说,如果每次render都会导致你传递的函数发生变化,可能会对它造成非常大的困扰。同时也不利于react做渲染优化。 不过还有一种场景,大家很容易忽视,而且还很容易将useCallback与useMemo混淆,典型场景就是:节流防抖。 举个例子:
1function BadDemo() {2 const [count, setCount] = useState(1);3 const handleClick = debounce(() => {4 setCount(c => ++c);5 }, 1000);6 return <div onClick={handleClick}>{count}</div>;7}
我们希望防止用户连续点击触发多次变更,加了个防抖,停止点击1秒后才触发 count + 1 ,这个组件在理想逻辑下是OK的。但现实是骨感的,我们的页面组件非常多,这个 BadDemo 可能由于父级什么操作就重新render了。现在假使我们页面每500毫秒会重新render一次,那么就是这样:
1function BadDemo() {2 const [count, setCount] = useState(1);3 const [, setRerender] = useState(false);4 const handleClick = debounce(() => {5 setCount(c => ++c);6 }, 1000);7 useEffect(() => {8 // 每500ms,组件重新render9 window.setInterval(() => {10 setRerender(r => !r);11 }, 500);12 }, []);13 return <div onClick={handleClick}>{count}</div>;14}
每次render导致handleClick其实是不同的函数,那么这个防抖自然而然就失效了。这样的情况对于一些防重点要求特别高的场景,是有着较大的线上风险的。 那怎么办呢?自然是想加上 useCallback :
1const handleClick = useCallback(debounce(() => {2 setCount(c => ++c);3}, 1000), []);
现在我们发现效果满足我们期望了,但这背后还藏着一个惊天大坑。
假如说,这个防抖的函数有一些依赖呢?比如setCount(c => ++c)
; 变成了 setCount(count + 1)
。那这个函数就依赖了 count 。代码就变成了这样:
1const handleClick = useCallback(2 debounce(() => {3 setCount(count + 1);4 }, 1000),5 []6);
大家会发现,你的lint规则,竟然不会要求你把 count 作为依赖项,填充到deps数组中去。这进而导致了最初的那个问题,只有第一次点击会count++。这是为什么呢?
因为传入useCallback的是一段执行语句,而不是一个函数声明。只是说它执行以后返回的新函数,我们将其作为了 useCallback 函数的入参,而这个新函数具体是个啥,其实lint规则也不知道。
更合理的姿势应该是使用 useMemo :
1const handleClick = useMemo(2 () => debounce(() => {3 setCount(count + 1);4 }, 1000),5 [count]6);
这样保证每当 count 发生变化时,会返回一个新的加了防抖功能的新函数。
思考
- React是如何识别纯函数组件和类组件的?